Skip to content

fix(mesh): hold device commands across a reconnect instead of losing them - #1291

Merged
dylanneve1 merged 5 commits into
mainfrom
feat/mesh-command-hold
Oct 16, 2026
Merged

dylanneve1 merged 5 commits into
mainfrom
feat/mesh-command-hold

Conversation

@dylanneve1

@dylanneve1 dylanneve1 commented Oct 11, 2026 •

Copy link
Copy Markdown
Collaborator

Phase 2 of the mesh HA design (notes/mesh-ha/DESIGN.md §1.5, §4 Phase 2 "Command outbox"; also mesh audit 2026-10-09 #1).

Problem

BridgeServer.sendToDevice writes to whichever streams claim the device right now. When the device is between streams — every device reconnects after a daemon restart (~1.6×/day), and after any network change or sseMaxLifetimeMs cycle — the frame went to nobody, and the command then waited out its whole timeout (12 s default, up to 305 s for exec/transfers) for an answer that could not come.

Worse: if any unclaimed legacy client was connected at that moment, a reconnecting device's command (exec command line, one-time transfer token, file bodies) was handed to that client instead. The new test holds a reconnecting device's command instead of handing it to an unclaimed client shows main doing this.

Change

  • sendToDevice now returns where the frame went: "claimed" | "fallback" | "none". A device id that has claimed a stream since the server started is never handed to the unclaimed fallback — it is reconnecting, so the answer is "none". Devices that never claimed (pre-claim builds) keep the fallback exactly as before.
  • BridgeServer.onDeviceStream(listener) fires when a stream claiming a device opens (after it is addressable).
  • MeshTransport.command may return false = "no live connection to that device". MeshService then holds the command for commandHoldMs (default 20 000, capped by the command's own timeout), offers it again on MeshService.deviceConnected(deviceId) (oldest first), and otherwise fails fast:
    Pixel 9 has no live connection to the bridge and did not reconnect within 20s, so "ring" was not delivered.
    Transports returning void (all existing ones, tests) count as delivered — no behaviour change for them.
  • New config native.commandHoldMs (0–300 000; 0 = fail at once).
  • The bridge→mesh wiring moved from native/index.ts into native/mesh-transport.ts (unchanged locate path), so the real wiring is tested end to end.

Offline-by-presence devices are still refused up front by dispatchCommand, so the hold only applies to devices that look online — i.e. the reconnecting case.

Tests (all fail on origin/main, pass here)

  • mesh-service.test.ts › MeshService command hold across a reconnect (6): delivered after reconnect; fails fast with "not delivered" inside the hold window (main: burns the 5 s timeout, "did not answer"); not resent after failing; other device's reconnect leaves it held; hold capped by command timeout; zero hold.
  • native-command-hold.test.ts (4), real server + SSE + registerMeshTransport + MeshService: command sent before the device's stream opens (fresh daemon) arrives when it opens (main: never arrives); reconnecting device's exec params are not shown to an unclaimed client and arrive on the new stream (main: leaked to the legacy client); fail fast; sendToDevice return values.

npm run preflight green (gitleaks not installed on this host).

Not in this PR

  • bye/restart notice on shutdown (separate PR) — together they make restarts invisible to tool calls.
  • The hold is per process: commands issued in the old process die with it (callers live there too).

Stacked on #1292 (merge #1292 first)

This branch is now rebased onto #1292 (feat/mesh-restart-notice), so until #1292 merges the diff here also shows its commit. Conflict resolved in src/frontend/native/index.ts: this PR's move of registerMeshTransport into mesh-transport.ts is kept, along with #1292's RESTART_RECONNECT_AFTER_MS and its server.stop({ restart }).

Late-delivery fix (84790df, reviewer concern #2): a held command is no longer delivered with less than max(3 s, 10% of its timeout) left (COMMAND_DELIVERY_MARGIN_MS / COMMAND_DELIVERY_MARGIN_SHARE). The hold ends at that point and the command fails with the existing "was not delivered" error, so the caller knows it never ran and a retry can't run it twice. deviceConnected also refuses a command whose delivery time has passed, in case the hold timer has not fired yet. With the default 12 s timeout the hold is now at most 9 s. Tests: "delivery margin before the deadline" in mesh-service.test.ts (4 of 5 fail without the fix). The commandHoldMs config doc mentions the margin.

Pre-flight GREEN; go test -race ./... green.

🤖 Generated with Claude Code

…ht back

On stop the bridge now ends every event stream with a bye frame:
reason "restart" with reconnectAfterMs when a /restart or /update armed
a successor, reason "shutdown" otherwise. The successor binds the bridge
~10 s after the old process lets go, so the notice asks for 5 s.

talon-node reacts to the restart notice: it waits the advertised delay
(clamped to 30 s), then retries every second for up to two minutes
instead of climbing its 1 s -> 30 s doubling backoff, which used to bring
it back up to ~16 s after the successor was listening. A shutdown bye
is a plain closed stream. Nodes that predate the frame skip it, like any
non-command kind (TestDecodeCommandFrame over the fixture).

The events fixture gains a bye sample; the companion tolerates it.
@dylanneve1

Copy link
Copy Markdown
Collaborator Author

Independent review (second pass): ready once e914cf2 is in (pushed to this branch)

Checked: full diff plus sendToDevice/openStream/claimDevice, deliverCommand's settle paths (timeout, presence watch, hold timer, result), deviceConnected flush, every caller of sendToDevice, companion and talon-node /events claims. Ran mesh-service, native-command-hold, native-bridge-credentials, bridge-claims-bind-refused, protocol-auth-fixture (106 pass) and the preflight lane (green; gitleaks isn't installed here).

No double delivery: a command is only held when no transport took it, and it is removed from heldCommands before anything else can offer it again. No leaks: finish clears both timers, the presence watch and both maps, and the hold is capped by the caller's own timeout. Old transports that return void count as delivered, so they behave as before.

Defect found and fixed: e914cf2
claimers starts empty in every process. So in the exact gap this PR targets (just after a daemon restart, before the device's first new stream), a command for a reconnecting device still went to any unclaimed shared-token client that came back first (the desktop app or the CLI on the same host). Its exec line and transfer token went to the wrong stream. The command counted as "fallback", so it was never held and never re-offered.

  • Fix: a device that has used its own per-device credential binds every stream it opens to its id (claimDevice), so sendToDevice now treats it as addressable from process start (claimsItsStreams).
  • I added activeFor to the bridge's BridgeCredentialAuthority slice. The core store already had it.
  • New test: after a restart, holds a credentialed device's command instead of handing it to an unclaimed client. It fails without the fix (the secret appears on the legacy stream).
  • Shared-token devices are still only covered after their first claim in this process. That gap goes away as the fleet moves to tdc1.

Remaining concerns (ranked)

  1. Half-open streams. A frame written to a stream the server still thinks is live (phone changed networks, TCP not yet dead) counts as "claimed". It is lost and waits out its whole timeout. Only device-side acks fix this. It is the biggest remaining source of lost commands.
  2. Late delivery near the deadline. The hold is capped by the timeout but the timeout is not extended. With the default 12 s, a device that returns at 11.5 s gets the command with 0.5 s left: it may execute (ring, exec) while the caller is told "did not answer", and a retry runs it twice. Owner decision: stop flushing when less than a few seconds remain, or restart the answer clock on flush.
  3. Merge conflict with feat(mesh): tell devices when the daemon restarts so they come straight back #1292 in src/frontend/native/index.ts (this PR moves registerMeshTransport out; feat(mesh): tell devices when the daemon restarts so they come straight back #1292 adds RESTART_RECONNECT_AFTER_MS next to it). Trivial to resolve.

…them

A command for a device that is between event streams (every device
reconnects after a daemon restart, ~1.6x/day) was written to nobody, or
to an unclaimed legacy client, and then waited out its whole timeout
(up to 305 s for exec).

The bridge now reports where sendToDevice put a frame (claimed /
fallback / none) and never hands a device that has claimed a stream to
the unclaimed fallback. The mesh holds a command no transport took for
native.commandHoldMs (default 20 s, capped by the command's timeout),
sends it the moment the device's stream reopens, and otherwise fails
fast with "has no live connection to the bridge … was not delivered".

The bridge-to-mesh wiring moves to frontend/native/mesh-transport.ts so
the real transport can be tested end to end.
…s after a restart

The "has claimed a stream" set starts empty in every process, so in the
gap the hold exists for (just after a daemon restart) a command for a
device that is still reconnecting went to whichever unclaimed shared-token
client was back first (the desktop app, the CLI): exec command lines and
transfer tokens on the wrong stream, the command counted as delivered and
never offered to the device when it returned.

A device that has authenticated with its own per-device credential binds
every stream it opens to its id, so it is addressable from the moment the
process starts: sendToDevice now treats it like a device that has claimed
a stream (held, "none"), not as a pre-claim build.
The hold was capped by the command's timeout but the timeout was not
extended, so a device back with 0.5 s left got the command: it could
ring or exec on the device after the caller was told it did not answer,
and a retry would run it twice.

A held command now stops being deliverable max(3 s, 10% of its timeout)
before the deadline (COMMAND_DELIVERY_MARGIN_MS / _SHARE). The hold
timer fails it as undelivered at that point, and deviceConnected also
refuses one whose delivery time has passed in case the timer has not
fired yet. With the default 12 s timeout the hold is now at most 9 s.

Tests: the "delivery margin before the deadline" cases in
mesh-service.test.ts; four fail without the fix.
@dylanneve1
dylanneve1 force-pushed the feat/mesh-command-hold branch from e914cf2 to 84790df Compare October 11, 2026 02:48
dylanneve1 pushed a commit that referenced this pull request Oct 11, 2026
…veFrontend

With #1291 (command hold) on top, createNativeFrontend reached 151 lines
and tripped the function-size ratchet in the integrated tree. The
restart notice's stop options move into bridgeStopOptions(), no change
in behaviour.
@dylanneve1

Copy link
Copy Markdown
Collaborator Author

Stack merge order and integrated test results are in #1286 (comment). This PR is 7th of 8. It is now rebased onto #1292 (merge that first) and adds the late-delivery margin fix (84790df).

@dylanneve1
dylanneve1 added this pull request to the merge queue Oct 11, 2026
@github-merge-queue
github-merge-queue Bot removed this pull request from the merge queue due to a conflict with the base branch Oct 11, 2026
# Conflicts:
#	apps/node/bridge.go
#	apps/node/restart_notice_test.go
#	src/frontend/native/index.ts
@dylanneve1
dylanneve1 added this pull request to the merge queue Oct 16, 2026
Merged via the queue into main with commit 7ed9545 Oct 16, 2026
26 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants